Repository Public
1import type { Metadata } from "next";2import Link from "next/link";3import { notFound } from "next/navigation";4import { NodeLivePanel } from "@/components/live/NodeLive";5import { chipShort, LifecycleBadge } from "@/components/nodes/bits";6import { Photo } from "@/components/ui/Photo";7import { nodeById, nodes, photoById, publishedBenchmarks } from "@/lib/data";8import { formatDate, getDict, LOCALES, type Locale } from "@/lib/i18n";9import { pageMetadata } from "@/lib/seo";1011type Props = { params: Promise<{ locale: Locale; id: string }> };1213function Rows({ rows }: { rows: [string, React.ReactNode][] }) {14 return (15 <dl className="divide-y divide-line border-y border-line">16 {rows.map(([k, v]) => (17 <div key={k} className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-4 py-3.5">18 <dt className="text-[0.875rem] text-mute">{k}</dt>19 <dd className="num text-[0.9375rem]">{v}</dd>20 </div>21 ))}22 </dl>23 );24}2526export const dynamicParams = false;27export const generateStaticParams = () => LOCALES.flatMap((locale) => nodes.map((n) => ({ locale, id: n.id })));2829export async function generateMetadata({ params }: Props): Promise<Metadata> {30 const { locale, id } = await params;31 const node = nodeById(id);32 if (!node) return {};33 const t = getDict(locale);34 return pageMetadata(locale, "nodes", `/nodes/${id}`, { title: `${node.publicName} — ${t.common.families[node.family]}${node.chip ? ` ${chipShort(node.chip)}` : ""}`, description: node.description[locale] });35}3637export default async function NodePage({ params }: Props) {38 const { locale, id } = await params;39 const node = nodeById(id);40 if (!node) notFound();41 const t = getDict(locale);42 const d = t.nodes.detail;43 const nd = <span className="text-mute">{t.common.notDocumented}</span>;44 const idx = nodes.findIndex((n) => n.id === node.id);45 const prev = nodes[idx - 1];46 const next = nodes[idx + 1];47 const photo = node.imageIds.map(photoById).find(Boolean);48 const bench = publishedBenchmarks.filter((b) => b.nodeId === node.id);49 const gb = (v: number | null) => (v !== null ? `${v} ${t.common.gb}` : nd);5051 const specs: [string, React.ReactNode][] = [52 [d.family, t.common.families[node.family]],53 [d.model, node.modelIdentifier ?? nd],54 [d.chip, node.chip ?? nd],55 [d.cpu, node.cpuCores ?? nd],56 [d.layout, node.cpuCoreLayout ?? nd],57 [d.gpu, node.gpuCores ?? nd],58 [d.memory, gb(node.unifiedMemoryGB)],59 [d.storage, gb(node.storageGB)],60 [d.network, node.networkInterface ? (locale === "fr" ? node.networkInterface.replace("Ethernet (1000BASE-T, negotiated link)", "Ethernet (1000BASE-T, lien négocié)") : node.networkInterface) : nd],61 ];62 const placement: [string, React.ReactNode][] = [63 [d.rack, node.rack ? t.nodes.filters.rackLabel(node.rack) : nd],64 [d.shelf, node.shelf ?? nd],65 [d.added, node.addedAt ? <time dateTime={node.addedAt}>{formatDate(node.addedAt, locale)}</time> : nd],66 ];67 return (68 <article className="shell pt-8 md:pt-14">69 <p><Link className="link text-[0.875rem]" href={`/${locale}/nodes`}>← {t.common.back}</Link></p>70 <header className="mt-7 grid gap-6 pb-8 md:grid-cols-12 md:pb-10">71 <div className="md:col-span-8">72 <p className="eyebrow">{d.eyebrow} · {String(node.number).padStart(2, "0")}</p>73 <h1 className="display mt-5 text-[clamp(2.75rem,1.4rem+6vw,5.5rem)]">{node.publicName}</h1>74 <p className="mt-5 text-xl tracking-[-0.01em] text-body">{t.common.families[node.family]}{node.chip ? ` · ${chipShort(node.chip)}` : ""}{node.unifiedMemoryGB !== null ? ` · ${node.unifiedMemoryGB} ${t.common.gb}` : ""}</p>75 </div>76 <div className="flex flex-col items-start gap-3 md:col-span-4 md:items-end md:justify-end">77 <LifecycleBadge state={node.lifecycle} label={t.common.lifecycle[node.lifecycle]} />78 <p className="label">{t.common.verified} {node.verifiedAt ? <time dateTime={node.verifiedAt} className="text-body">{formatDate(node.verifiedAt, locale)}</time> : nd}</p>79 </div>80 </header>8182 <NodeLivePanel id={node.id} locale={locale} />8384 <div className="grid gap-14 pt-12 lg:grid-cols-12 lg:gap-12 md:pt-16">85 <div className="lg:col-span-7">86 <section aria-labelledby="specs-h">87 <h2 id="specs-h" className="label mb-4">{d.specs}</h2>88 <Rows rows={specs} />89 <p className="mt-3 text-[0.8125rem] text-mute">{d.layoutLegend}</p>90 </section>91 <section aria-labelledby="fn-h" className="mt-12">92 <h2 id="fn-h" className="label mb-4">{d.function}</h2>93 <p className="prose-tech">{node.description[locale]}</p>94 <ul className="mt-5 flex flex-wrap gap-2">95 {node.role.map((r) => <li key={r} className="rounded-full border border-line-strong bg-surface px-3 py-1 text-[0.8125rem] text-body">{t.common.roles[r]}</li>)}96 </ul>97 </section>98 <section aria-labelledby="pl-h" className="mt-12">99 <h2 id="pl-h" className="label mb-4">{d.placement}</h2>100 <Rows rows={placement} />101 </section>102 <section aria-labelledby="hist-h" className="mt-12 grid gap-8 sm:grid-cols-2">103 <div>104 <h2 id="hist-h" className="label mb-3">{d.history}</h2>105 <p className="text-[0.9375rem] leading-relaxed text-body">{d.historyNone}</p>106 </div>107 <div>108 <h2 className="label mb-3">{d.benchmarks}</h2>109 {bench.length === 0 ? (110 <p className="text-[0.9375rem] leading-relaxed text-body">{d.benchmarksNone} <Link className="link" href={`/${locale}/benchmarks`}>{t.nav.benchmarks}</Link></p>111 ) : (112 <p className="text-[0.9375rem]"><Link className="link" href={`/${locale}/benchmarks`}>{bench.length} — {t.nav.benchmarks}</Link></p>113 )}114 </div>115 </section>116 </div>117118 <aside className="lg:col-span-5">119 <section aria-labelledby="ph-h">120 <h2 id="ph-h" className="label mb-4">{d.photo}</h2>121 {photo ? (122 <figure>123 <div className="photo-frame"><Photo photo={photo} locale={locale} sizes="(min-width: 1024px) 38vw, 100vw" className="h-auto w-full" /></div>124 <figcaption className="mt-3 text-[0.8125rem] leading-relaxed text-mute">{photo.title[locale]}. {d.photoNote}</figcaption>125 </figure>126 ) : (127 <p className="card border-dashed p-6 text-[0.9375rem] leading-relaxed text-body">{d.photoNone}</p>128 )}129 </section>130 <section aria-labelledby="prov-h" className="mt-10">131 <h2 id="prov-h" className="label mb-4">{d.provenance}</h2>132 <Rows rows={[[t.common.verified, node.verifiedAt ? formatDate(node.verifiedAt, locale) : nd], [d.status, t.common.lifecycle[node.lifecycle]]]} />133 <p className="mt-3 text-[0.8125rem] leading-relaxed text-mute" lang="en">{t.common.source}: {node.source ?? t.common.notDocumented}</p>134 </section>135 <section aria-labelledby="cav-h" className="mt-10">136 <h2 id="cav-h" className="label mb-4">{d.caveats}</h2>137 <ul className="space-y-2 text-[0.875rem] leading-relaxed text-body">138 {d.caveatItems.map((c) => <li key={c} className="flex gap-3"><span aria-hidden="true" className="mt-[0.7em] h-px w-3 shrink-0 bg-accent" />{c}</li>)}139 </ul>140 </section>141 </aside>142 </div>143144 <nav aria-label={t.nav.nodes} className="mt-20 grid grid-cols-2 gap-4 border-t border-line pt-8">145 <div>{prev ? <Link className="group block" href={`/${locale}/nodes/${prev.id}`}><span className="label">← {d.prev}</span><span className="mt-2 block text-xl font-medium group-hover:text-accent">{prev.publicName}</span></Link> : null}</div>146 <div className="text-right">{next ? <Link className="group block" href={`/${locale}/nodes/${next.id}`}><span className="label">{d.next} →</span><span className="mt-2 block text-xl font-medium group-hover:text-accent">{next.publicName}</span></Link> : null}</div>147 </nav>148 </article>149 );150}151